//******************************************************************
//******************************************************************
//**********          ANts Peer To Peer Sources        *************
//
// ANts P2P realizes a third generation P2P net. It protects your
// privacy while you are connected and makes you not trackable, hiding
// your identity (ip) and crypting everything you are sending/receiving
// from others.

// Copyright (C) 2004  Roberto Rossi

// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.

// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.

// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.


package ants.p2p.utils.net;

import java.net.InetAddress;
import java.net.Inet4Address;
import java.net.UnknownHostException;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.io.IOException;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Enumeration;
import java.util.Random;
import java.util.Set;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.cybergarage.upnp.Action;
import org.cybergarage.upnp.Argument;
import org.cybergarage.upnp.ControlPoint;
import org.cybergarage.upnp.Device;
import org.cybergarage.upnp.DeviceList;
import org.cybergarage.upnp.Service;
import org.cybergarage.upnp.device.DeviceChangeListener;
import org.cybergarage.upnp.control.*;

import ants.p2p.gui.FrameAnt;
import org.apache.log4j.*;
import org.cybergarage.upnp.StateVariable;
import java.io.File;
import org.cybergarage.upnp.device.InvalidDescriptionException;
import org.cybergarage.upnp.ServiceList;
import ants.p2p.filesharing.WarriorAnt;

/**
 *
 * According to the UPnP Standards, Internet Gateway Devices must have a
 * specific hierarchy.  The parts of that hierarchy that we care about are:
 *
 * Device: urn:schemas-upnp-org:device:InternetGatewayDevice:1
 * 	 SubDevice: urn:schemas-upnp-org:device:WANDevice:1
 *     SubDevice: urn:schemas-upnp-org:device:WANConnectionDevice:1
 *        Service: urn:schemas-upnp-org:service:WANIPConnection:1
 *
 * Every port mapping is a tuple of:
 *  - External address ("" is wildcard)
 *  - External port
 *  - Internal address
 *  - Internal port
 *  - Protocol (TCP|UDP)
 *  - Description
 *
 * Port mappings can be removed, but that is a blocking network operation which will
 * slow down the shutdown process of Limewire.  It is safe to let port mappings persist
 * between limewire sessions. In the meantime however, the NAT may assign a different
 * ip address to the local node.  That's why we need to find any previous mappings
 * the node has created and update them with our new address. In order to uniquely
 * distinguish which mappings were made by us, we put part of our client GUID in the
 * description field.
 *
 * For the TCP mapping, we use the following description: "ANts/TCP:<cliengGUID>"
 *
 * NOTES:
 *
 * Not all NATs support mappings with different external port and internal ports. Therefore
 * if we were unable to map our desired port but were able to map another one, we should
 * pass this information on to Acceptor.
 *
 * Some buggy NATs do not distinguish mappings by the Protocol field.  Therefore
 * we first map the UDP port, and then the TCP port since it is more important should the
 * first mapping get overwritten.
 *
 * The cyberlink library uses an internal thread that tries to discover any UPnP devices.
 * After we discover a router or give up on trying to, we should call stop().
 *
 */
public class UPnPManager extends ControlPoint implements DeviceChangeListener, ActionListener, QueryListener {

  private static Logger _logger = Logger.getLogger(UPnPManager.class.getName());

  private final static String DESCRIPTION_FILE_NAME = WarriorAnt.workingPath+"upnpdescriptor/description.xml";
  private final static String PRESENTATION_URI = "http://antsp2p.sourceforge.net";

  public final static String ANTS_DEVICE_TYPE = "urn:schemas-upnp-org:device:antsp2p:1";
  public final static String ANTS_LAN_ADDRESS_SERVICE_TYPE = "urn:schemas-upnp-org:service:address:1";
  public final static String ANTS_LAN_ADDRESS_ACTION_TYPE = "GetLANAddress";

  private StateVariable serverInfo;

  private Device antsDev = null;

    /** some schemas */
    private static final String ROUTER_DEVICE=
            "urn:schemas-upnp-org:device:InternetGatewayDevice:1";
    private static final String WAN_DEVICE =
            "urn:schemas-upnp-org:device:WANDevice:1";
    private static final String WANCON_DEVICE=
            "urn:schemas-upnp-org:device:WANConnectionDevice:1";
    private static final String SERVICE_TYPE =
            "urn:schemas-upnp-org:service:WANIPConnection:1";

    /** prefixes and a suffix for the descriptions of our TCP and UDP mappings */
    private static final String TCP_PREFIX = "ANtsTCP";
    //private static final String UDP_PREFIX = "ANtsUDP";
    private String _guidSuffix;

    private static UPnPManager INSTANCE = null;

    public static synchronized UPnPManager instance() {
        if(INSTANCE == null){
          UPnPDescriptor.createDirectoryStructure();
          return INSTANCE = new UPnPManager();
        }else{
          return INSTANCE;
        }
    }

    /**
     * the router we have and the sub-device necessary for port mapping
     *  LOCKING: this
     */
    private Device _router;

    /**
     * The port-mapping service we'll use.  LOCKING: this
     */
    private Service _service;

    /** The tcp and udp mappings created this session */
    private Mapping _tcp;//, _udp;

    private UPnPManager(){
        super();
        _logger.info("Starting UPnP Service Manager.");
        try{
            addDeviceChangeListener(this);
            start();
        }catch(Exception bad) {
            bad.printStackTrace();
        }
    }

    public Device getDevice(){
      return this.antsDev;
    }

    public void setCurrentLanAddress(ants.p2p.query.ServerInfo si){
      try{
        if (si == null || this.serverInfo == null)
          return;
        this.serverInfo.setValue(si.toString());
      }catch(Exception e){
        _logger.error("Error in setting UPnP LAN address", e);
      }
    }

    public ants.p2p.query.ServerInfo getCurrentLanAddress(){
      try{
        if(this.serverInfo == null) return null;
        String[] addressPort = this.serverInfo.getValue().split(":");
        ants.p2p.query.ServerInfo si = new ants.p2p.query.ServerInfo("", addressPort[0], new Integer(addressPort[1]),"");
        return si;
      }catch(Exception e){
        _logger.error("Error in retrieving UPnP LAN address", e);
        return null;
      }
    }

    public void startDevice(){
      stopDevice();
      try {
        antsDev = new Device(DESCRIPTION_FILE_NAME);

        Action getTimeAction = antsDev.getAction("GetLANAddress");
        getTimeAction.setActionListener(this);

        ServiceList serviceList = antsDev.getServiceList();
        Service service = serviceList.getService(0);
        service.setQueryListener(this);

        serverInfo = antsDev.getStateVariable("LANAddress");

        antsDev.setLeaseTime(60);
        antsDev.start();
      }
      catch (InvalidDescriptionException e) {
        _logger.error("Invalid description file for ANtsP2P UPnP Service", e);
      }
    }

    public void stopDevice(){
      if(antsDev != null) {
        antsDev.byebye();
        antsDev.stop();
        this.antsDev = null;
        this.serverInfo = null;
      }
    }

    public boolean queryControlReceived(StateVariable stateVar){
            //MUST SET THE LAN ADDRESS HERE (SERVER INFO)!!!!
            stateVar.setValue(serverInfo.getValue());
            return true;
    }

    public boolean actionControlReceived(Action action){
            String actionName = action.getName();
            if (actionName.equals("GetLANAddress") == true) {
                    Argument timeArg = action.getArgument("LANAddress");
                    timeArg.setValue(serverInfo.getValue());
                    return true;
            }
            return false;
    }

    public void update(String newValue) {
            serverInfo.setValue(newValue);
    }

    /**
     * @return whether we are behind an UPnP-enabled NAT/router
     */
    public synchronized boolean isNATPresent() {
            return _router != null && _service != null;
    }

    /**
     * @return whether we have created mappings this session
     */
    public /*synchronized*/ boolean mappingsExist() {
            return _tcp != null /*&& _udp != null*/;
    }

    /**
     * @return the external address the NAT thinks we have.  Blocking.
     * null if we can't find it.
     */
    public InetAddress getNATAddress() throws UnknownHostException {
            Action getIP;

        synchronized(this) {
                if (!isNATPresent())
                        return null;
                getIP = _service.getAction("GetExternalIPAddress");
        }

        if(getIP == null) {
            _logger.info("Couldn't find GetExternalIPAddress action!");
            return null;
        }


        if (!getIP.postControlAction()) {
                _logger.info("couldn't get our external address");
                return null;
        }

        Argument ret = getIP.getOutputArgumentList().getArgument("NewExternalIPAddress");
        return InetAddress.getByName(ret.getValue());
    }

    /**
     * this method will be called when we discover a UPnP device.
     */
    public synchronized void deviceAdded(Device dev) {
        _logger.info("Device added: " + dev.getFriendlyName());

            // we've found what we need
            if (_service != null && _router != null) {
                    _logger.info("we already have a router");
                    return;
            }

            // did we find a router?
            if (dev.getDeviceType().equals(ROUTER_DEVICE) && dev.isRootDevice())
                    _router = dev;

            if (_router == null) {
                    _logger.info("didn't get router device");
                    return;
            }

            discoverService();

            // did we find the service we need?
            if (_service == null) {
                    _logger.info("couldn't find service");
                    _router=null;
            } else {
                    _logger.info("Found service, router: " + _router.getFriendlyName() + ", service: " + _service);
                    //stop();
            }
	}

	/**
	 * Traverses the structure of the router device looking for
	 * the port mapping service.
	 */
	private void discoverService() {

		for (Iterator iter = _router.getDeviceList().iterator();iter.hasNext();) {
			Device current = (Device)iter.next();
			if (!current.getDeviceType().equals(WAN_DEVICE))
				continue;

			DeviceList l = current.getDeviceList();
				_logger.info("found "+current.getDeviceType()+", size: "+l.size() + ", on: " + current.getFriendlyName());

			for (int i=0;i<current.getDeviceList().size();i++) {
				Device current2 = l.getDevice(i);

				if (!current2.getDeviceType().equals(WANCON_DEVICE))
					continue;

					_logger.info("found "+current2.getDeviceType() + ", on: " + current2.getFriendlyName());

				_service = current2.getService(SERVICE_TYPE);
				return;
			}
		}
	}

	/**
	 * adds a mapping on the router to the specified port
	 * @return the external port that was actually mapped. 0 if failed
	 */
	public int mapPort(int port) {
	        _logger.info("Attempting to map port: " + port);

		Random gen=null;

		String localAddress = FrameAnt.getInstance(null).getGuiAnt().getConnectionAntPanel().getLocalStringAddress();
		int localPort = port;

		// try adding new mappings with the same port
		/*Mapping udp = new Mapping("",
				port,
				localAddress,
				localPort,
				"UDP",
				UDP_PREFIX + getGUIDSuffix());

		// add udp first in case it gets overwritten.
		// if we can't add, update or find an appropriate port
		// give up after 20 tries
		int tries = 20;
		while (!addMapping(udp)) {
			if (tries<0)
				break;
			tries--;

			// try a random port
			if (gen == null)
				gen = new Random();
			port = gen.nextInt(50000)+2000;
			udp = new Mapping("",
					port,
					localAddress,
					localPort,
					"UDP",
					UDP_PREFIX + getGUIDSuffix());
		}

		if (tries < 0) {
			_logger.info("couldn't map a port :(");
			return 0;
		}*/

		// at this stage, the variable port will point to the port the UDP mapping
		// got mapped to.  Since we have to have the same port for UDP and tcp,
		// we can't afford to change the port here.  So if mapping to this port on tcp
		// fails, we give up and clean up the udp mapping.
		Mapping tcp = new Mapping("",
				port,
				localAddress,
				localPort,
				"TCP",
				TCP_PREFIX + getGUIDSuffix());
		if (!addMapping(tcp)) {
			_logger.info(" couldn't map tcp to whatever udp was mapped. cleaning up...");
			port = 0;
			tcp = null;
			//udp = null;
		}

		// save a ref to the mappings
		synchronized(this) {
			_tcp = tcp;
			//_udp = udp;
		}

		// we're good - start a thread to clean up any potentially stale mappings
		Thread staleCleaner = new Thread(new StaleCleaner());
		staleCleaner.setDaemon(true);
		staleCleaner.setName("Stale Mapping Cleaner");
		staleCleaner.start();

		return port;
	}

	/**
	 * @param m Port mapping to send to the NAT
	 * @return the error code
	 */
	private boolean addMapping(Mapping m) {

			_logger.info("adding "+m);

		Action add;
		synchronized(this) {
			add = _service.getAction("AddPortMapping");
		}

		if(add == null) {
		    _logger.info("Couldn't find AddPortMapping action!");
		    return false;
		}


		add.setArgumentValue("NewRemoteHost",m._externalAddress);
		add.setArgumentValue("NewExternalPort",m._externalPort);
		add.setArgumentValue("NewInternalClient",m._internalAddress);
		add.setArgumentValue("NewInternalPort",m._internalPort);
		add.setArgumentValue("NewProtocol",m._protocol);
		add.setArgumentValue("NewPortMappingDescription",m._description);
		add.setArgumentValue("NewEnabled","1");
		add.setArgumentValue("NewLeaseDuration",0);

		boolean success = add.postControlAction();
		    _logger.info("Post succeeded: " + success);
		return success;
	}

	/**
	 * @param m the mapping to remove from the NAT
	 * @return whether it worked or not
	 */
	private boolean removeMapping(Mapping m) {

			_logger.info("removing "+m);

		Action remove;
		synchronized(this) {
			remove = _service.getAction("DeletePortMapping");
		}

		if(remove == null) {
		    _logger.info("Couldn't find DeletePortMapping action!");
		    return false;
	    }

		remove.setArgumentValue("NewRemoteHost",m._externalAddress);
		remove.setArgumentValue("NewExternalPort",m._externalPort);
		remove.setArgumentValue("NewProtocol",m._protocol);

		boolean success = remove.postControlAction();
		    _logger.info("Remove succeeded: " + success);
		return success;
	}

	/**
	 * schedules a shutdown hook which will clear the mappings created
	 * this session.
	 */
	public void clearMappingsOnShutdown() {
		final Mapping tcp/*, udp*/;
		synchronized(this) {
			tcp = _tcp;
			//udp = _udp;
		}

		final Thread cleaner = new Thread() {
			public void run() {
				_logger.info("start cleaning");
				removeMapping(tcp);
				//removeMapping(udp);
				_logger.info("done cleaning");
			}
		};

		Thread waiter = new Thread() {
		    public void run() {
		        try{
		            _logger.info("waiting for UPnP cleaners to finish");
		            cleaner.join(30000); // wait at most 30 seconds.
		        }catch(InterruptedException ignored){}
		        _logger.info("UPnP cleaners done");
		    }
		};
		waiter.setName("shutdown mapping waiter");

		try {
		    Runtime.getRuntime().addShutdownHook(waiter);
		} catch (IllegalStateException ignored){}

		cleaner.setName("shutdown mapping cleaner");
		cleaner.setDaemon(true);
		cleaner.start();
		Thread.yield(); // let it start.
	}

	public void finalize() {
		stop();
	}

	private synchronized String getGUIDSuffix() {
	    if (_guidSuffix == null) FrameAnt.getInstance(null).getGuiAnt().getConnectionAntPanel().getWarriorAnt().getIdent().substring(0,10);
	    return _guidSuffix;
	}
	/**
	 * stub
	 */
	public void deviceRemoved(Device dev) {}

	/**
	 *  @return A non-loopback IPv4 address of a network interface on the
         * local host.
         * @throws UnknownHostException
         */
   	public static InetAddress getLocalAddress()
     	  throws UnknownHostException {
            InetAddress addr = InetAddress.getLocalHost();
            if (addr instanceof Inet4Address && !addr.isLoopbackAddress())
                return addr;

            try {
               Enumeration interfaces =
                   NetworkInterface.getNetworkInterfaces();

               if (interfaces != null) {
                   while (interfaces.hasMoreElements()) {
                       Enumeration addresses =
                            ((NetworkInterface)interfaces.nextElement()).getInetAddresses();
                       while (addresses.hasMoreElements()) {
			   addr = (InetAddress) addresses.nextElement();
                           if (addr instanceof Inet4Address && !addr.isLoopbackAddress()) {
                               return addr;
                           }
                       }
		   }
	       }
            } catch (SocketException se) {}

       	    throw new UnknownHostException(
               "localhost has no interface with a non-loopback IPv4 address");
   	}

	private final class Mapping {
		public final String _externalAddress;
		public final int _externalPort;
		public final String _internalAddress;
		public final int _internalPort;
		public final String _protocol,_description;

		// network constructor
		public Mapping(String externalAddress,String externalPort,
				String internalAddress, String internalPort,
				String protocol, String description) throws NumberFormatException{
			_externalAddress=externalAddress;
			_externalPort=Integer.parseInt(externalPort);
			_internalAddress=internalAddress;
			_internalPort=Integer.parseInt(internalPort);
			_protocol=protocol;
			_description=description;
		}

		// internal constructor
		public Mapping(String externalAddress,int externalPort,
				String internalAddress, int internalPort,
				String protocol, String description) {
			_externalAddress=externalAddress;
			_externalPort=externalPort;
			_internalAddress=internalAddress;
			_internalPort=internalPort;
			_protocol=protocol;
			_description=description;
		}

		public String toString() {
			return _externalAddress+":"+_externalPort+"->"+_internalAddress+":"+_internalPort+
				"@"+_protocol+" desc: "+_description;
		}

	}

	/**
	 * This thread reads all the existing mappings on the NAT and if it finds
	 * a mapping which appears to be created by us but points to a different
	 * address (i.e. is stale) it removes the mapping.
	 *
	 * It can take several minutes to finish, depending on how many mappings there are.
	 */
	private class StaleCleaner implements Runnable {

	    // TODO: remove
	    private String list(java.util.List l) {
	        String s = "";
	        for(Iterator i = l.iterator(); i.hasNext(); ) {
	            Argument next = (Argument)i.next();
	            s += next.getName() + "->" + next.getValue() + ", ";
	        }
	        return s;
	    }

		public void run() {

		    _logger.info("Looking for stale mappings...");

			Set mappings = new HashSet();
			Action getGeneric;
			synchronized(UPnPManager.this) {
				getGeneric = _service.getAction("GetGenericPortMappingEntry");
			}

			if(getGeneric == null) {
			    _logger.info("Couldn't find GetGenericPortMappingEntry action!");
			    return;
			}

			// get all the mappings
			try {
				for (int i=0;;i++) {
    				getGeneric.setArgumentValue("NewPortMappingIndex",i);
				        _logger.info("Stale Iteration: " + i + ", generic.input: " + list(getGeneric.getInputArgumentList()) + ", generic.output: " + list(getGeneric.getOutputArgumentList()));

					if (!getGeneric.postControlAction())
						break;

					mappings.add(new Mapping(
							getGeneric.getArgumentValue("NewRemoteHost"),
							getGeneric.getArgumentValue("NewExternalPort"),
							getGeneric.getArgumentValue("NewInternalClient"),
							getGeneric.getArgumentValue("NewInternalPort"),
							getGeneric.getArgumentValue("NewProtocol"),
							getGeneric.getArgumentValue("NewPortMappingDescription")));
				    // TODO: erase output arguments.

				}
			}catch(NumberFormatException bad) {
			    _logger.error("NFE reading mappings!", bad);
				//router broke.. can't do anything.
				return;
			}

				_logger.info("Stale cleaner found "+mappings.size()+ " total mappings");

			// iterate and clean up
			for (Iterator iter = mappings.iterator();iter.hasNext();) {
				Mapping current = (Mapping)iter.next();
				    _logger.info("Analyzing: " + current);

				if(current._description == null)
				    continue;

				// does it have our description?
				if (current._description.equals(TCP_PREFIX+getGUIDSuffix())/* ||
						current._description.equals(UDP_PREFIX+getGUIDSuffix())*/) {

					// is it not the same as the mappings we created this session?
					synchronized(this) {

						if (_tcp != null && /*_udp != null &&*/
                                                    current._externalPort == _tcp._externalPort &&
                                                    current._internalAddress.equals(_tcp._internalAddress) &&
                                                    current._internalPort == _tcp._internalPort)
							continue;
					}

					// remove it.
						_logger.info("mapping "+current+" appears to be stale");
					removeMapping(current);
				}
			}
		}
	}
}
